Skip to content

Fix undercompilation when a case class field type changes and the class is used in a pattern match#26262

Draft
retronym wants to merge 2 commits into
scala:mainfrom
retronym:fix/26231-case-class-pattern-match-undercompilation
Draft

Fix undercompilation when a case class field type changes and the class is used in a pattern match#26262
retronym wants to merge 2 commits into
scala:mainfrom
retronym:fix/26231-case-class-pattern-match-undercompilation

Conversation

@retronym

@retronym retronym commented Jun 8, 2026

Copy link
Copy Markdown
Member

Fixes #26231

Problem

Changing a case class field type causes a NoSuchMethodError at runtime because the file pattern-matching on it is not recompiled by Zinc.

Scala 3 generates def unapply(x: C): C = x for case classes — its signature never changes regardless of field types. ExtractDependencies runs before PatternMatcher, so the _1(), _2(), … calls that PatternMatcher will emit are not yet in the tree. Since _1 is never recorded as a used name, Zinc doesn't recompile the dependent file when _1's return type changes.

Fix

Add a case UnApply(fun, ...) branch to recordTree that records each product selector (_1, _2, …) as a used name via addMemberRefDependency. Note: fun.tpe is a TermRef, so .widen is needed before .finalResultType to reach the underlying MethodType and then the case class type.

How much have you relied on LLM-based tools in this contribution?

Extensively, for investigation and implementation.

How was the solution tested?

New automated tests

Three new assertions in ExtractAPISpecification: that _1's API hash changes when the field type changes; that unapply's hash does not (documenting why the old code failed); and that _1 now appears in the used names for a file that pattern-matches on the case class.

retronym and others added 2 commits June 9, 2026 08:52
When a case class field type is changed, files that pattern-match on it
are not recompiled by Zinc, leading to NoSuchMethodError at runtime.

Root cause: Scala 3 generates `def unapply(x: C): C = x` whose signature
is stable regardless of field types. ExtractDependencies records `unapply`
as a used name but never `_1`, which is the method the generated bytecode
actually calls. Since `unapply`'s name hash never changes, Zinc skips
recompilation of the dependent file.

The three new tests document:
- `_1` API hash changes when field type changes (expected)
- `unapply` API hash does NOT change (the stable signature hiding the change)
- `_1` is absent from the used names recorded for a pattern-matching file (the bug)

Fixes scala#26231

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…26231)

In Scala 3, the compiler generates `def unapply(x: C): C = x` for case
classes.  Its signature is always `(C): C` regardless of field types, so
its API hash never changes when a field is renamed or retyped.

The PatternMatcher phase (which runs after ExtractDependencies) lowers
`case C(x)` to a direct call to the product selector `_1()`, `_2()`, etc.
Because ExtractDependencies ran before PatternMatcher, those selector
calls were never in the tree, so `_1` was never recorded as a used name
in the dependent file.  Zinc therefore saw no reason to recompile the
file when the field type changed, producing a NoSuchMethodError at
runtime.

Fix: in `AbstractExtractDependenciesCollector.recordTree`, add a case for
`UnApply` that records each product selector (`_1`, `_2`, …) found on
the unapply's result type as a member-reference used name.  When the
selector's return type changes (because the field type changed) its name
hash changes and Zinc correctly invalidates the dependent file.

The key detail is that `fun.tpe` in an `UnApply` node is a `TermRef`;
we must call `.widen.finalResultType` to reach the underlying case-class
type before asking `Applications.productSelectors` for the `_N` members.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@retronym retronym force-pushed the fix/26231-case-class-pattern-match-undercompilation branch from a68989d to b9fafe3 Compare June 8, 2026 22:53
//
// fun.tpe is a TermRef; widen first to reach the underlying MethodType,
// then take finalResultType to get the case class type (e.g. Customer2).
val selectors = productSelectors(fun.tpe.widen.finalResultType)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pessimistically assumes that all selectors are relevant, this could be refined to exclude those that are not bound by patterns.

@retronym

retronym commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

I suspected this is just one of a class of bugs:

=========== CLAUDE ===========

Fix for #26231 — undercompilation when a
case class field type changes and a downstream file pattern-matches on it.

Summary of the change

Scala 3 generates def unapply(x: C): C = x for case classes, so the unapply signature
never changes when field types change. PatternMatcher (which runs after
ExtractDependencies) lowers case C(x, y) to calls to the product selectors _1, _2, …,
so those calls are never in the tree that Zinc dependency extraction sees. The fix adds a
case UnApply to recordTree in compiler/src/dotty/tools/dotc/sbt/ExtractDependencies.scala
that records each product selector of the unapply result type as a member-reference used name.

Verdict

✅ Correct for the targeted scenario.

  • The original bug reproduces end-to-end on released Scala 3.7.3:
    NoSuchMethodError: 'java.lang.String C._2()' at runtime after changing a field type,
    with no recompilation of the matching file. Recording _1.._N makes the selector's
    name hash change, which invalidates the dependent file.
  • Field removal is also covered: the _N name disappears, which Zinc treats as a change.
  • The new case is additive: traverse calls recordTree per node and traverses children
    independently, so fun and sub-patterns are still visited. Inlined code also benefits via
    the recordTree call in transform/Inlining.scala.
  • Non-product result types (Option[...], Boolean, custom wrappers) degrade harmlessly:
    productSelectors returns Nil.

Nits

  • case UnApply(fun, implicits, patterns)implicits and patterns are unused; use
    UnApply(fun, _, _).
  • The comment references Customer2, a leftover from the issue repro.
  • Import reshuffling / blank-line churn at the top of the file is unrelated to the fix.
  • Known mild overapproximation: a file matching tuples records _1/_2 and can be
    spuriously invalidated when an unrelated dependency's _2 changes. Inherent to
    simple-name hashing; acceptable.
  • The sbt-bridge test was not run locally (needs a full compiler build) — confirm
    sbt-bridge/testOnly xsbt.ExtractAPISpecification is green in CI.

Related bugs found (all confirmed empirically on Scala 3.7.3, not covered by this fix)

Repro harness: one sbt module, upstream
case class C(a: String, b: String); object A { val c = C("first", "second") },
downstream file pattern-matching A.c.

  1. Named pattern + field rename → stale success.
    Downstream case C(b = bv); renaming b upstream should produce
    No element named 'b' is defined in selector type C, but the file is not recompiled.
    Named patterns are desugared to positional form in Desugar.adaptPatternArgs using
    caseAccessors; the field name is never recorded as a used name.

  2. Named pattern + reordering same-typed fields → silently extracts the wrong field.
    Incremental run printed b=second; clean build prints b=first. No crash, no error,
    wrong data — arguably worse than Undercompilation in all scala3/sbt versions leading to NoSuchMethodError on pattern matching #26231. Nothing hashable changes: unapply is
    (C): C and _1/_2 are both String before and after.

  3. Adding a field → stale success.
    Positional case C(av, bv) should fail with "Wrong number of argument patterns" after a
    third field is added, but is not invalidated: _1/_2 are unchanged and _3 is a new
    name nobody used.

  4. Name-based extractors → runtime crash.
    def unapply(x: String): W where W has isEmpty/get: changing get's result type
    gives NoSuchMethodError: 'java.lang.String W.get()' without recompilation.
    Same root cause — PatternMatcher emits the isEmpty/get calls after
    ExtractDependencies runs.

Repro pitfall worth documenting

Any downstream file using string interpolation appears to be invalidated correctly —
but only by accident: s"..." desugars to StringContext.apply, and Zinc hashes used
names by simple name, so any case-class signature change (which changes C.apply)
collides and invalidates the file. End-to-end repros must avoid apply entirely.
The branch's sbt-bridge test asserts on used names directly and is immune.

Suggested follow-ups

NamedArg nodes survive into the typed UnApply tree (-Xprint:typer shows
case C(_, b = bv @ _)), so ExtractDependencies can detect named patterns:

  • When a sub-pattern is a NamedArg, record the primary constructor (C;init;) or
    companion apply as a used name. Verified via Zinc debug logs that its hash changes on
    field rename, reorder, retype, and arity change → fixes bugs 1 and 2.
  • Recording <init> for all case-class unapplies would also fix bug 3 (arity increase),
    at the cost of overcompiling positional matches when constructor params are merely renamed.
  • For non-product unapply result types, record isEmpty/get (name-based extractor
    protocol), and the lengthCompare/apply/drop family for unapplySeq → fixes bug 4.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Undercompilation in all scala3/sbt versions leading to NoSuchMethodError on pattern matching

1 participant